fix: ingest resolves a schema scope and qualifies non-default schemas (DEV-1758) - #294
Conversation
duckdb_engine's get_table_names(schema=None) returns objects from every schema as bare names -- every other Tier-1 dialect restricts it to the connection's default -- so _build_one_model wrote an unqualified sql_table for a non-default-schema object and the generator emitted `FROM reports`, which fails table-not-found. The same schema-blindness made _get_columns_fallback union two same-named tables' columns together. Ingest now resolves an explicit schema scope: --schema a,b / --all-schemas (schemas / all_schemas on the Python, REST and MCP surfaces), else datasource.schema_name, else the connection default. Multi-schema is opt-in because it changes what sql_table holds; when one schema is scanned and others exist, the run prints which and exits 0. Two different strings, easy to conflate: * the discovery token is carried exactly as get_schema_names() yields it (catalog-qualified on DuckDB), because the qualified form is the safe one -- with a catalog ATTACHed, a bare `main` makes get_table_names and has_table reach into it and makes the column fallback return the cross-catalog union. is_default therefore compares tokens in full. * the emitted qualifier is the bare last segment; the connection's current catalog is already correct. Both INFORMATION_SCHEMA fallbacks now filter on table_catalog as well as table_schema. table_schema alone holds the bare name, so a qualified token matched nothing: silently column-less models from the column fallback, and -- on DuckDB, where the Inspector reports no PK even for a declared PRIMARY KEY, so the fallback is the path that runs -- every primary key dropped. Only non-default schemas are qualified, so widening the scan never rewrites models already on disk; a single explicitly-named schema is written verbatim, preserving --schema public -> public.orders. Re-ingest heals a MISSING qualifier (participating in the short-circuit and the save gate, like source_kind) but never rewrites one, and two schemas' same-named tables are never fused into one model -- including the case a schema comparison alone cannot see, where the persisted model is unqualified because it IS the default schema's table. Collisions resolve in one phase over final model names with a 4-key total order, so the mixed sanitize/cross-schema case is defined and the outcome never depends on inspector listing order. validate-models derives its schema set from the models being validated and keys the live map on the full <schema_token>.<object> identity, with shorter aliases inserted only when unique -- an ambiguous alias is dropped so a lookup misses rather than resolving to another catalog's same-named table. That is a data-loss path: an unresolvable model is a WholeModelDelete that --force-clean acts on. One dotted-name splitter replaces three disagreeing parsers, so hand-written Snowflake db.schema.table and BigQuery project.dataset.table stop losing their catalog. No new model field and no migration -- the schema lives in sql_table, which is where the generator already reads it. Closes DEV-1758 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reachedYou’ve reached a temporary PR review limit under our Fair Usage Limits Policy. Next review available in: 36 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (16)
Comment |
…iases Six findings from the Codex review of the PR diff, four of them defects I introduced, all reproduced against DuckDB before fixing. **Bare schema names still swept ATTACHed catalogs.** The token discipline was applied to the schemas we ENUMERATE but not to the ones handed to us: an explicit `--schema main`, a persisted `schema_name`, and the bare qualifier `validate-models` reads back off `sql_table` all went to the Inspector verbatim. Measured, `--schema main` on a database with a second catalog attached ingested that catalog's `only_in_other` and wrote it as `main.only_in_other`, which does not exist in the current catalog -- the exact bug class this branch exists to remove. `resolve_schema_token` upgrades such a name to the enumerated catalog-qualified token, preferring the current catalog when several expose the same schema name, and `ResolvedSchema` now carries `requested_as` so the emitted `sql_table` stays what the user typed. Resolving for discovery must never change the SQL we persist. **Dropping every contested alias was itself a data-loss bug.** The live map dropped a short alias claimed by more than one object, meaning to avoid an arbitrary winner. But default-schema models are persisted UNQUALIFIED by design, so as soon as another schema gained a same-named table, a legacy `sql_table: orders` stopped resolving -- and an unresolvable model is a `WholeModelDelete` that `validate-models --force-clean` deletes. A contested alias now resolves to the DEFAULT schema's entry, which is what the database itself does: `FROM orders` and `FROM main.orders` both land in the current catalog. It is dropped only when the default cannot break the tie. **The cross-schema guard failed open.** `_default_schema_object_names` converted a failed listing into an empty list, which reads as "no such default-schema object" and waved the qualifier repair through -- repointing a model at another schema's table. Unknown is now `None` and distinct from empty, and refuses the merge. Skipping a legal repair costs a re-run. **The PK fallback joined across catalogs.** DuckDB names a PK constraint after its column, so a same-shaped table in an ATTACHed catalog gets the identical auto-generated name; joining `key_column_usage` on constraint name and schema alone matched both and returned `['id', 'id']`. The join now carries the catalog. One finding rejected: emitting a 3-part `sql_table` for an explicitly-named catalog-qualified schema is by design, and verified queryable on DuckDB. Also from SonarQube: `# noqa: CODE — prose` is malformed suppression syntax (python:S7632), so the reasons move to their own line; the alias indexing is extracted into `_index_live_entries`, which also settles the cognitive complexity finding on `_live_schema_for_datasource`; and one composite test assertion is split. Three existing tests updated -- they pinned the pre-fix behaviour: the discovery token is now qualified where it used to be bare, and the contested alias resolves rather than missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings from review round 2, both reproduced first. **Two requests naming the same schema cancelled each other out.** `validate-models` derives its schema set from persisted `sql_table` values, so a datasource holding both `orders` (bare ingest) and `main.customers` (`--schema main`) asks for `None` AND `main` -- which now resolve to the same discovery token. Scanned twice, every object appeared as two rival claimants for its own alias, the default-schema tie-break found no unique winner, and the aliases were dropped from objects that have no rival at all. Measured: BOTH models became WholeModelDeletes, i.e. the fix for the previous round's data-loss bug had opened a wider one. Resolved tokens are now deduplicated before scanning, and `_index_live_entries` collapses duplicate `(schema_token, object)` pairs so the helper is correct whatever it is fed. **The catalog upgrade assumed every dot is a catalog separator.** Postgres allows `CREATE SCHEMA "foo.bar"` and lists schema names bare, so a request for a nonexistent `bar` would have silently resolved to `foo.bar` and ingested a schema the user never asked for. The upgrade is now gated on the dialect actually enumerating catalog-qualified tokens, decided from its own default schema token rather than from "does any name contain a dot". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fallback Two findings from review round 3. **The gate could misclassify DuckDB as bare.** It asked `qualified_default_schema()`, which falls back to the BARE default when the current catalog cannot be determined -- and with attached catalogs supplying several `*.main` tokens, that fallback fires. The gate then reported "this dialect lists bare names", refused the upgrade, and re-armed the cross-catalog sweep the upgrade exists to prevent. It now asks where the dialect's own default schema turns up in its own enumeration: listed bare means bare tokens; absent but present as some `<catalog>.<default>` means the dialect qualifies. That is independent of catalog detection, and still keeps Postgres' `CREATE SCHEMA "foo.bar"` from being read as a catalog. **`None` was not normalised before the scan dedupe.** `None` and an explicit `main` resolved to different values (`None` stays `None`) even though `list_ingestable_objects` resolves both to the same token internally, so the schema was introspected twice and correctness rested entirely on the entry-level dedupe behind it. `None` now normalises to the default token up front. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|



Closes DEV-1758.
The bug
duckdb_engine'sInspector.get_table_names(schema=None)returns objects from every schema as bare names. Postgres, MySQL, SQL Server, Snowflake, BigQuery, ClickHouse and SQLite all restrict it to the connection's default schema, so DuckDB is the only exposed Tier-1 dialect._build_one_modelthen wroteso a non-default-schema object got an unqualified
sql_table, the generator emittedFROM reports, and the query failed with table-not-found. Models in the default schema resolved through the search path, which is what made the breakage look partial rather than systemic — the reporter had 3 of 29 models working.The same schema-blindness hit
introspect_utils._get_columns_fallback: withschema is Noneit issued aninformation_schema.columnsquery with no schema filter, somain.reports(a)ands2.reports(b, c)produced one model with columns[a, b, c]. On DuckDB that is not a rare fallback —Inspector.get_columnsalways raises (pg_catalog.pg_collation does not exist) — so it is the primary column path.Third defect:
cli._run_datasources_createbuilt itsDatasourceConfigfrom name / type / connection_string / description only, usingargs.schemafor the one-shot ingest and then discarding it, whileschema_drift._collect_sql_table_diffsreadsdatasource.schema_nameback. Sodatasources create --schema X --ingestfollowed by a bareslayer ingestscanned a different schema thanvalidate-modelsinspected. (MCP'screate_datasourcealready persisted it; the CLI was the odd one out.)Not literally a regression from #283
Worth stating plainly since the issue title says otherwise: the
sql_tableassignment is byte-identical before and after #283, and--schemahas always qualified correctly. What #283 changed is visibility — views are now ingested by default, and dbt materialises staging models as views, so a dlt+dbt DuckDB file that previously produced a handful of models now produces dozens, most of them in a non-default schema and therefore unqueryable. The issue'sv7: sql_table: main.stg_reactionsevidence comes from the dbt-import path (slayer/dbt/converter.pypassesschema=rm.schema_name), not from bareslayer ingest. The bug is real and fixed as reported; only the framing is off.Repro, before and after
Before:
(_duckdb.CatalogException) Table with name reports does not exist! Did you mean "openfda_rest.reports"? [SQL: SELECT COUNT(*) ... FROM reports AS reports]After:
What changed
Schema scope. One ingest pass covers one schema unless told otherwise: explicit
--schema a,b/--all-schemas(schemas/all_schemason the Python, REST and MCP surfaces), elsedatasource.schema_name, else the connection default. Multi-schema is opt-in because it changes whatsql_tableholds.schema_nameis a fallback, never a conflict with an explicit flag; the genuine conflicts (schema+schemas,all_schemas+either) are rejected by one shared helper called from every entry point, so the CLI'sadd_mutually_exclusive_groupis not the only thing holding the line. When exactly one schema is scanned and others exist, the run says which — a hint, not a failure, so the exit code is unchanged.Two different strings. This is the part that is easy to get backwards, and I got it backwards first — the plan review's initial resolution said normalise tokens to bare, and direct measurement showed the exact inverse. With
att_other.duckdbattached asaaatoatt_main.duckdb, wheresharedexists in both:mainatt_main.mainget_table_names['in_default','shared','only_in_other','shared']sweeps the attached catalog['in_default','shared']has_table('only_in_other')TrueFalse_get_columns_fallback('shared')['m','o']union[](fixed below)get_schema_names()on DuckDB returns catalog-qualified tokens always, with or without an ATTACH. So the discovery token is carried exactly as enumerated, end to end, andis_defaultcompares tokens in full — a last-segment comparison is precisely what madeatt_main.mainandother.mainboth read as the default. The emitted qualifier is a different string: the bare last segment, since the connection's current catalog is already the right one and re-stating it would only break if the datasource were repointed.test_column_fallback_never_unions_across_catalogspins the['m','o']hazard directly, so the withdrawn rule cannot come back silently.Both INFORMATION_SCHEMA fallbacks filter on
table_catalog.table_schemaalone holds the bare name, so a qualified token matched nothing. For the column fallback that meant a model persisted with zero columns and no error; there is deliberately no bare-token retry, since retrying bare is exactly what reintroduces the union. For the PK fallback — which on DuckDB is the path that actually runs, because its Inspector reports an emptyconstrained_columnseven for a declaredPRIMARY KEY— it meant every primary key silently dropped, and fan-out safety leans onColumn.primary_key. Non-DuckDB dialects carry no catalog segment, so the predicate is never added and their emitted SQL is byte-identical to today's (TestFallbackSqlShapepins that).With no schema at all the fallback can no longer be narrowed, so instead of unioning every match it groups rows by catalog+schema: one group is used, the default breaks a tie, anything still ambiguous raises. Lowest-sorted-wins was rejected deliberately — it swaps union corruption for wrong-table corruption, which is harder to notice. Per-object isolation turns the raise into a reported skip, so one ambiguous object never aborts the run.
Which objects get qualified. Only non-default schemas, so widening the scan never rewrites models already on disk and one datasource legitimately mixes both forms. A single explicitly-named schema is written verbatim, preserving today's
--schema public->public.orders. A multi-schema list is deliberately not verbatim: listing the default alongside another schema would re-qualify every existing model.--all-schemasmeans the current catalog only; attached catalogs are dropped loudly, with the exact--schema <catalog>.<schema>invocation that ingests them.Merging. Re-ingest heals a missing qualifier but never rewrites an existing one. The repair has to participate in the short-circuit and the save gate (same reason
source_kinddoes — a repair usually changes no columns, so a merge that only edits themodel_copy(update=...)dict computes the fix and throws it away). Two schemas' same-named tables are never fused into one model: a schema mismatch skips, and — the case a schema comparison alone cannot see, because default-schema models are persisted unqualified — a bare persistedsql_tablenaming a real default-schema object also skips, rather than being repointed by the heal.Collisions resolve in one phase over final model names with a 4-key total order (unsanitized beats sanitized, then default schema, then schema name, then object name) rather than successive passes, so the mixed case (
s1.a__bsanitizing onto a reals2.a_b) is defined and the outcome never depends on inspector listing order. Losers skip, never suffix.validate-models derives its schema set from the models being validated — no new flag — and keys the live map on the full
<schema_token>.<object>identity plus shorter aliases. A contested alias resolves to the DEFAULT schema's entry, mirroring what the database itself does (FROM ordersandFROM main.ordersboth land in the current catalog); it is dropped only when the default cannot break the tie. This is a data-loss path, not a false-positive nuisance: an unresolvable model becomes aWholeModelDelete, whichvalidate-models --force-cleanacts on.Schema names that arrive from outside — a
--schemaargument, a persistedschema_name, the bare qualifier read back off a persistedsql_table— are upgraded to the enumerated catalog-qualified token before they reach an Inspector, since a bare token is precisely what sweepsATTACHed catalogs. What the user typed is kept separately (ResolvedSchema.requested_as) and is what gets emitted, so resolving for discovery can never change the SQL persisted.One dotted-name splitter (
split_sql_table, everything before the final dot) replaces three parsers that disagreed about three-part names, so hand-written Snowflakedb.schema.tableand BigQueryproject.dataset.tablestop losing their catalog. That bug exists today, independent of this feature.No new model field and no migration — the schema lives in
sql_table, which is where the generator already reads it.SlayerModelstays at version 8.Review round 1 (Codex)
Six findings on the first commit, four of them defects I introduced, all reproduced against DuckDB before fixing — see
558c06dc.--schema mainwith a second catalog attached ingested that catalog'sonly_in_otherand wrote it asmain.only_in_other, which does not exist in the current catalog. Fixed byresolve_schema_token+requested_as, described above.sql_table: ordersstopped resolving →WholeModelDelete→ deleted by--force-clean. Now resolved the way the database resolves it.['id', 'id']. The join now carries the catalog.One finding rejected: emitting a 3-part
sql_tablefor an explicitly-named catalog-qualified schema is by design (explicit means verbatim), and verified queryable on DuckDB.SonarQube:
# noqa: CODE — proseis malformed suppression syntax (python:S7632), so the reasons moved to their own line; extracting_index_live_entriesalso settled the cognitive-complexity finding on_live_schema_for_datasource; one composite test assertion split.Tests
New
tests/test_ingestion_schema_qualification.py: 135 tests over real temp.duckdbfiles (unit-scoped, the patterntest_cube_js_e2e_duckdb.pyalready uses — not integration-marked). Written before the implementation; the suite failed to import onIngestSchemaScopeuntil the feature existed.Full non-integration suite: 7520 passed, 5 skipped, 4 xfailed (7385 before this branch). Ruff clean. DuckDB integration suites re-run green.
Five changes to existing tests, all because the behaviour they pinned genuinely changed:
test_ingestion.py::test_without_schemaasserted"table_schema" not in sql_strand 2-tuple rows. The schema-blind query now selects catalog and schema so it can group instead of union. Updated to assert what actually matters — still parameterized, and no:schemabound.test_column_fallback_never_unions_across_catalogsasserted["m","o"];ORDER BY ordinal_positionsays nothing about which of two catalogs sorts first, and DuckDB returned["o","m"]. Compared sorted.test_requested_schemas_are_marked_explicit,test_multi_returns_objects_tagged_with_their_schema), and a contested alias now resolves instead of missing (test_ambiguous_short_keys_are_dropped_not_overwritten, renamed).tests/test_ingestion_name_sanitize.pypasses unchanged, including itss.table_name == "a__b"bare-label assertion — the schema-qualified skip label is used only when the object set actually spans more than one schema.The PK regression above was caught by
tests/integration/test_ingestion_jaffle_shop.py, not by the unit suite, because nothing in it asserted a primary key on DuckDB.TestPrimaryKeysAreSchemaAwarecloses that; both of its tests fail when the fix is reverted.Docs
docs/reference/cli.md(new "Which schemas get ingested" section, both flag tables),docs/concepts/ingestion.md(new "Schema scope" section with the four-surface table),docs/concepts/models.md(whensql_tablemust be qualified),docs/configuration/datasources.md(schema_nameand ingestion),.claude/skills/slayer-models.md,.claude/skills/slayer-overview.md, and a datedDECISIONS.mdentry. No new pages, sozensical.tomlnav is unchanged.Known limitations, documented not fixed
--all-schemascovers the current catalog only. Schemas in anATTACHed DuckDB catalog are reported as skipped with the explicit invocation that ingests them, rather than guessed at.sql_tablevalues within one datasource. That is the deliberate consequence of not rewriting default-schema models..stays unrepresentable. Pre-existing.🤖 Generated with Claude Code